Short-circuit comparison chains in rewritten asserts - #14918
Conversation
Python evaluates a comparison chain lazily: in a < b < c, c is never evaluated when a < b is false. visit_Compare walked the comparators in a loop, assigning every result, and only combined them with an and afterwards. By then everything had already run, so a rewritten assert could call what Python would not, and could fail with an unrelated exception instead of AssertionError. Nest each comparison after the first inside an if on the previous result, which is what visit_BoolOp already does for and/or since pytest-dev#57. The explanation builds its arguments eagerly, so a skipped operand would leave an unbound name behind. Everything a skipped operand would have bound is set to None before the chain. Nothing reads those values: _call_reprcompare stops at the first false result, and a chain only short-circuits after one. Closes pytest-dev#14819. Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
An operand can contribute statements that run only when the assertion fails: a nested boolop appends to an explanation list built in the main statement body. Nesting only the main statements left those appends running unconditionally, against a list the skipped operand never bound, so assert 1 < 0 < (a or b) raised AttributeError on None. Nest expl_stmts on the same condition. Most operands add nothing there, and an If with an empty body is not valid ast, so the empty ones are dropped afterwards, innermost first: pruning a child can empty its parent. Collect the names to pre-bind by walking the conditional blocks for Store targets, rather than tracking self.variables and format_variables by index. That picks up @py_format names too, which pop_format_context only records when the assertion_pass hook is enabled, and drops a branch that no test could reach. Co-authored-by: Claude <noreply@anthropic.com>
|
I moved this to draft for a few hours because I found a case my own change broke, and I would rather flag it than have a reviewer find it. Nesting only the main statement body was not enough. An operand can also contribute statements that run only when the assertion fails — a nested boolop builds its explanation by appending to a list that is created in the main body. With the list creation skipped and the appends left running unconditionally: def test_boolop_in_a_chain():
a = b = 0
assert 1 < 0 < (a or b)That is worse than the bug being fixed. The full suite did not catch it — there was no test for a boolop inside a comparison chain — so I found it by reading the generated code with Fixed by nesting
|
|
please crosscheck if this is already part of my stack of rewrite bugfixes/enhancements |
|
Sorry, I just checked and duplicated the result; I had some leftover GPT tokens and wanted to help out with computing power—I'm auditing and helping more effectively now. My apologies; you're doing great work, brother. |
|
Sorry, I just checked and duplicated the result; I had some leftover GPT tokens and wanted to help out with computing power—I'm auditing and helping more effectively now. My apologies; you're doing great work, brother. |
|
Cross-checked as you asked: #14822 already does this, from three weeks ago, and reaches the same shape. Closing in favour of your stack. One thing that came out of the comparison — I left the detail on #14822 — is that a boolop in a skipped comparator raises assert 1 < 0 < (a or b) # AttributeError: 'NoneType' object has no attribute 'append'
Sorry for the duplicate. I filtered issues on "no assignee, no comments" and #14819 is both, being your own — I should have looked for a PR referencing it before starting. |
Closes #14819.
The problem
Python evaluates a comparison chain lazily: in
a < b < c,cis never evaluated whena < bis false.visit_Comparewalked the comparators in a loop, assigning every result, and only combined them with anandafterwards — by which point everything had already run.Both pass under
--assert=plainand fail when rewritten.The change
Each comparison after the first is nested inside an
ifon the previous result — whatvisit_BoolOphas done forand/orsince #57. Forassert 1 < 0 < boom():The
andin the guard already short-circuits, soresis safe. The explanation is not: it builds its arguments eagerly, and a skipped operand would leave an unbound name for_safereprto read. Everything a skipped operand would have bound is set toNonebefore the chain.Nothing ever reads those
Nones._call_reprcomparebreaks at the first falsy result and reports only that pair — which is exactly where the chain stopped, since a chain only short-circuits after a false comparison. That is also why the messages do not change:assert 1 < 3 < 5 <= 4 < 7still reportsassert 5 <= 4.Notes for review
Noneassignment is inserted aftercomp.left's own statements, so a compound left operand is still evaluated exactly once and in place. Withassert len(v) < g() < h()the generated code keeps@py_assert2 = len(v)first and only callsh()inside the guard.format_variablesonly exists when thepytest_assertion_passhook is enabled, so it is read throughgetattr.Tests
test_comparison_chain_short_circuitscovers the wrong-exception case, the unwanted-call case, and that a chain which fails partway still reports the failing pair.test_comparisonsandtest_custom_reprcomparepin the existing messages and are unchanged.Full suite: 4359 passed, 97 skipped, 14 xfailed.
Checklist
Co-authored-bycommit trailers.changelogdirectory.AUTHORSin alphabetical order.Claude (Opus 5) was used; it is credited in the
Co-authored-bytrailers. I read the generated code withast.unparsebefore and after, and I can answer questions on any part of it.